Skip to content

浅析React中的domDiff

目前主流的前端框架Vue和React,都引入了虚拟DOM的机制,这不可避免的涉及到从虚拟DOM和真实DOM的对比过程,寻找差异后更新到真实DOM树,完成视图的渲染。 React的设计更加的复杂,它不仅有虚拟DOM,为了提升渲染性能,还引入了Fiber架构模式,这让它的Diff过程比较独特。

Diff的策略

Diff的过程是,比较两颗结点树的差异,然后patch,以最小化的成本来更新视图。完全比较两颗树的差异算法是O(n^3)的时间复杂度。假如页面上有1000个结点,消耗的性能非常惊人。

Alt text

每个结点都去和另一棵树上的结点,逐一比较一次,再加上更新的操作。

js
1000 ** 3 = 1,000,000,000 // 10亿
1000 ** 3 = 1,000,000,000 // 10亿

假如页面上的结点更多的话,每次更新一个数据状态,都要如此庞大的性能消耗,页面肯定会比较卡顿。为了减少结点Diff过程中的性能消耗,React框架在设计的时候,制定了3条策略。

  1. 只进行同级的结点比较,跨层级的结点不处理

Alt text

默认页面上的绝大部分操作都是在同一级内展开的(同一个父级容器内),跨级操作的概率会比较低。

  1. 根据结点的type类型比较,遇到类型不同直接标记为删除

Alt text

第三个结点,由li标签变成的p标签,这种情况会直接销毁li-c,重新创建成p标签

  1. 会根据结点key标识,来尽量复用结点

Alt text

这样,新旧列表中的结点类型一致且都能找相同key的结点,只需要移动结点的位置即可。

通过上述这三种Diff策略的实施,将结点树的对比时间复杂度由 O(n^3) 降低到了 O(n)

React中Diff的本质

在介绍 React的结点Diff过程前,我们先熟悉下React的fiber架构

Alt text

在上面图中所示,每个结点有三个指针,return 指向父级结点,sibling指向相邻的兄弟结点,child指向第一子结点。所有的fiber结点构成了,一个棵链表树。

在每次 setState 的过程中,React都会进行调和(reconcile)的过程,就是拿JSX生成ReactElement 和 上次更新时对应的 Fiber结点比较,这就是Diff的过程,将比较的结果生成新的Fiber结点。

一个DOM节点在某一时刻最多会有4个节点和他相关。

  • current Fiber。如果该DOM节点已在页面中,current Fiber代表该DOM节点对应的Fiber节点。
  • workInProgress Fiber。如果该DOM节点将在本次更新中渲染到页面中,workInProgress Fiber代表该DOM节点对应的Fiber节点。
  • DOM节点本身。
  • JSX对象。即ClassComponent的render方法的返回结果,或 FunctionComponent的调用结果。JSX对象中包含描述DOM节点的信息。

Diff的过程,就是 current Fiber结点 和 JSX对象比较生成 workInProgress Fiber,而真实的DOM结点的更新则发生在commit阶段。

Diff的入口函数 reconcileChildFibers 出发,该函数会根据newChild(即JSX对象)类型调用不同的处理函数。

js
// 根据newChild类型选择不同diff函数处理
function reconcileChildFibers(
  returnFiber: Fiber,
  currentFirstChild: Fiber | null,
  newChild: any,
): Fiber | null {

  const isObject = typeof newChild === 'object' && newChild !== null;

  if (isObject) {
    // object类型,可能是 REACT_ELEMENT_TYPE 或 REACT_PORTAL_TYPE
    switch (newChild.$$typeof) {
      case REACT_ELEMENT_TYPE:
        // 调用 reconcileSingleElement 处理
        // 单个 组件元素的处理
    }
  }

  if (typeof newChild === 'string' || typeof newChild === 'number') {
    // 调用 reconcileSingleTextNode 处理
    // 单个文本结点的处理
  }

  if (isArray(newChild)) {
    // 调用 reconcileChildrenArray 处理
    // 多个子节点的处理
  }

  // 一些其他情况调用处理函数

  // 以上都没有命中,删除节点
  return deleteRemainingChildren(returnFiber, currentFirstChild);
}
// 根据newChild类型选择不同diff函数处理
function reconcileChildFibers(
  returnFiber: Fiber,
  currentFirstChild: Fiber | null,
  newChild: any,
): Fiber | null {

  const isObject = typeof newChild === 'object' && newChild !== null;

  if (isObject) {
    // object类型,可能是 REACT_ELEMENT_TYPE 或 REACT_PORTAL_TYPE
    switch (newChild.$$typeof) {
      case REACT_ELEMENT_TYPE:
        // 调用 reconcileSingleElement 处理
        // 单个 组件元素的处理
    }
  }

  if (typeof newChild === 'string' || typeof newChild === 'number') {
    // 调用 reconcileSingleTextNode 处理
    // 单个文本结点的处理
  }

  if (isArray(newChild)) {
    // 调用 reconcileChildrenArray 处理
    // 多个子节点的处理
  }

  // 一些其他情况调用处理函数

  // 以上都没有命中,删除节点
  return deleteRemainingChildren(returnFiber, currentFirstChild);
}

单结点Diff

单个结点的处理流程

Alt text

  • 先判断是否存在上次的Fiber结点对应的DOM,不存在的话,直接新生成一个Fiber结点并返回
  • 存在的话,对比下DOM结点是否可复用,能复用的话,直接返回上次Fiber的副本
  • 假如不能复用,标记成 deletion,并新生成一个Fiber结点返回。
js
function reconcileSingleElement(
  returnFiber: Fiber,
  currentFirstChild: Fiber | null,
  element: ReactElement
): Fiber {
  const key = element.key;
  let child = currentFirstChild;
  
  // 首先判断是否存在对应DOM节点
  while (child !== null) {
    // 上一次更新存在DOM节点,接下来判断是否可复用

    // 首先比较key是否相同
    if (child.key === key) {

      // key相同,接下来比较type是否相同

      switch (child.tag) {
        // ...省略case
        
        default: {
          if (child.elementType === element.type) {
            // type相同则表示可以复用
            // 返回复用的fiber
            return existing;
          }
          
          // type不同则跳出switch
          break;
        }
      }
      // 代码执行到这里代表:key相同但是type不同
      // 将该fiber及其兄弟fiber标记为删除
      deleteRemainingChildren(returnFiber, child);
      break;
    } else {
      // key不同,将该fiber标记为删除
      deleteChild(returnFiber, child);
    }
    child = child.sibling;
  }

  // 创建新Fiber,并返回 ...省略
}
function reconcileSingleElement(
  returnFiber: Fiber,
  currentFirstChild: Fiber | null,
  element: ReactElement
): Fiber {
  const key = element.key;
  let child = currentFirstChild;
  
  // 首先判断是否存在对应DOM节点
  while (child !== null) {
    // 上一次更新存在DOM节点,接下来判断是否可复用

    // 首先比较key是否相同
    if (child.key === key) {

      // key相同,接下来比较type是否相同

      switch (child.tag) {
        // ...省略case
        
        default: {
          if (child.elementType === element.type) {
            // type相同则表示可以复用
            // 返回复用的fiber
            return existing;
          }
          
          // type不同则跳出switch
          break;
        }
      }
      // 代码执行到这里代表:key相同但是type不同
      // 将该fiber及其兄弟fiber标记为删除
      deleteRemainingChildren(returnFiber, child);
      break;
    } else {
      // key不同,将该fiber标记为删除
      deleteChild(returnFiber, child);
    }
    child = child.sibling;
  }

  // 创建新Fiber,并返回 ...省略
}

在代码的实现逻辑中

  1. 先比较结点的key是否相同,不同则不能复用,标记成 删除 状态
  2. 结点key相同的前提下,再比较type类型,类型一致,则可复用
  3. 结点key相同的前提下,假如type类型不一致的话,将该结点和其兄弟fiber结点都标记成 删除 状态

举个例子:

html
// before
section > div * 3
// after
section > h2
// before
section > div * 3
// after
section > h2

更新前是 三个div的子元素,更新后是一个h2标签,它们都同属一section容器。当h2的key已经匹配到第一个div时,此时由于div 和 h2是不同类型的结点, 既然h2 <--key匹配上--> div-1,那么后面的第二个和第三div已经没用了,且由于类型不同,会直接将这三个div全部标记删除状态。

当 h2 <--key没匹配上--> div-1,仅需要标记 div-1 删除状态即可,因为有可能 h2 <--key匹配上--> div-2 或者 h2 <--key匹配上--> div-3

当且仅当 key 和 type都相同时,才能复用结点,二者缺一不可。在该过程会优先使用key,key不同,则 type 就不会比较了,直接将结点标记 删除 状态

多结点Diff

当 reconcileChildFibers 方法中传递的 newChild参数类型为Array时,就开始多结点的Diff了。

html
<ul>
  <li key="a">A</li>
  <li key="b">B</li>
  <li key="c">C</li>
  <li key="d">D</li>
</ul>
<ul>
  <li key="a">A</li>
  <li key="b">B</li>
  <li key="c">C</li>
  <li key="d">D</li>
</ul>

可能的变化如下几种:

  1. 结点更新

(key一致的前提下)属性信息 或 内容信息 或 结点类型变化了

html
<ul>
  <!-- 增加了样式类名称【list-item】 -->
  <li key="a" class="list-item">A</li>
  <!-- 改变了内容信息: -->
  <li key="b">BBB</li>
  <li key="c">C</li>
  <!-- 标签类型变了 -->
  <p key="d">D</p>
</ul>
<ul>
  <!-- 增加了样式类名称【list-item】 -->
  <li key="a" class="list-item">A</li>
  <!-- 改变了内容信息: -->
  <li key="b">BBB</li>
  <li key="c">C</li>
  <!-- 标签类型变了 -->
  <p key="d">D</p>
</ul>
  1. 结点新增或减少
html
<!-- 结点增加情况 -->
<ul>
  <li key="a">A</li>
  <li key="b">B</li>
  <li key="c">C</li>
  <li key="d">D</li>
  <li key="e">E</li>
</ul>

<!-- 结点减少情况 -->
<ul>
  <li key="a">A</li>
  <li key="b">B</li>
  <li key="c">C</li>
</ul>
<!-- 结点增加情况 -->
<ul>
  <li key="a">A</li>
  <li key="b">B</li>
  <li key="c">C</li>
  <li key="d">D</li>
  <li key="e">E</li>
</ul>

<!-- 结点减少情况 -->
<ul>
  <li key="a">A</li>
  <li key="b">B</li>
  <li key="c">C</li>
</ul>
  1. 结点位置的改变
html
<ul>
  <li key="d">D</li>
  <li key="a">A</li>
  <li key="c">C</li>
  <li key="b">B</li>
</ul>
<ul>
  <li key="d">D</li>
  <li key="a">A</li>
  <li key="c">C</li>
  <li key="b">B</li>
</ul>

多结点的Diff比较,就在上面三种情况中。

Diff的总体思路

Alt text

由于 Fiber 结点是链表树的结构,而传递的 newChild 是JSXElement 类型,可以理解一个一维的数组结构。两者没法通过双端指针的比较方式,采用了两轮的比较策略。

  • 第一轮,发现更新情况的结点,并标记处理
  • 第二轮,处理非 更新情况的结点

第一轮,分别从 oldFiber的起始位置 和 newChild的索引为0的位置,逐个比较。

Alt text

发现发现可以复用的结点,i 和 start 分别向右挪动一个位置,直到 newChild 或 oldFiber 最后。

如果不能复用的话:

  • key不同导致的无法复用,直接结束第一轮遍历
  • key相同type不同导致的无法复用,会将 oldFiber 中的结点标记 删除状态,继续遍历

第一轮结束后,会有如下几种情况:

  1. oldFiber 和 newChild 都遍历完了,那么Diff过程结束
  2. oldFiber 没有遍历完,newChild 遍历完了,说明有 旧的结点会被删除
  3. oldFiber 遍历完,newChild 没有遍历完了,说明有 新的结点会被插入
  4. oldFiber 和 newChild 都没有遍历完,说明结点的位置有改动

第二轮遍历:

  • newChildren与oldFiber同时遍历完,最理想的情况,Diff结束
  • oldFiber 没有遍历完,newChild 遍历完了,需要遍历剩下的oldFiber,依次标记Deletion
  • oldFiber 遍历完,newChild 没有遍历完了,遍历剩下的newChildren为生成的workInProgress fiber依次标记 Placement
  • oldFiber 和 newChild 都没有遍历完,处理移动的结点

处理移动的结点

总体思路:

  1. 首先将 oldFiber 映射成 { key => FiberNode }的哈希Map,便于在遍历 newChild 时快速定位上
  2. 声明一个可以复用结点的最大索引位置 lastPlacedIndex,作为参照物,每次遍历 newChild 时 迭代更新
  3. 遍历 newChild 并找到其在 oldFiber 中的索引位置,记录为 currentIndex,拿 currentIndex 和 lastPlacedIndex。如果 currentIndex > lastPlacedIndex,则将 lastPlacedIndex = currentIndex,不断更新lastPlacedIndex的值,否则维持 lastPlacedIndex 不变

示例一

假如更新前是a b c d的结点顺序,更新后是 a c d b 的顺序

Alt text

第一轮结束后,lastPlacedIndex = 0,开启第二轮针对 newChild剩余结点的遍历。此时

js
oldFiber: [a, b, c, d]
newChild: [a, → c, d, b]
oldFiber: [a, b, c, d]
newChild: [a, → c, d, b]
  • c在oldFiber中的索引是 2,即 currentIndex = 2
  • 由于 currentIndex > lastPlacedIndex,意味着 c 结点不需要移动
  • 重置参照物结点的索引:lastPlacedIndex = currentIndex = 2

进行 newChild中 下一个结点 d的处理

js
oldFiber: [a, b, c, d]
newChild: [a, c, → d, b]
oldFiber: [a, b, c, d]
newChild: [a, c, → d, b]
  • d在oldFiber中的索引是 3,即 currentIndex = 3
  • 由于 currentIndex > lastPlacedIndex,意味着 d 结点不需要移动
  • 重置参照物结点的索引:lastPlacedIndex = currentIndex = 3

进行 newChild中 下一个结点 b的处理

js
oldFiber: [a, b, c, d]
newChild: [a, c, d, → b]
oldFiber: [a, b, c, d]
newChild: [a, c, d, → b]
  • b 在oldFiber中的索引是 1,即 currentIndex = 1
  • 上一次 lastPlacedIndex 是 3
  • 由于 currentIndex < lastPlacedIndex,意味着 b 结点需要移动
  • 参照物结点的索引,lastPlacedIndex 保持 3不变

最终 a c d 三个结点没有移动,只有 b 结点标记为移动状态

示例二

假如更新前是a b c d的结点顺序,更新后是 d a b c 的顺序

Alt text

第一轮结束后,没有结点可复用,开启第二轮针对 newChild剩余结点的遍历。首先处理 d 结点

js
oldFiber: [a, b, c, d]
newChild: [ → d, a, b, c]
oldFiber: [a, b, c, d]
newChild: [ → d, a, b, c]
  • d在oldFiber中的索引是 3,即 currentIndex = 3
  • 由于 currentIndex > lastPlacedIndex,意味着 d 结点不需要移动
  • 重置参照物结点的索引:lastPlacedIndex = currentIndex = 3

进行 newChild中 下一个结点 a 的处理

js
oldFiber: [a, b, c, d]
newChild: [d, → a, b, c]
oldFiber: [a, b, c, d]
newChild: [d, → a, b, c]
  • a 在oldFiber中的索引是 0,即 currentIndex = 0
  • 上一次 lastPlacedIndex 是 3
  • 由于 currentIndex < lastPlacedIndex,意味着 a 结点需要移动
  • 参照物结点的索引,lastPlacedIndex 保持 3不变

进行 newChild中 下一个结点 b 的处理

js
oldFiber: [a, b, c, d]
newChild: [d, a, → b, c]
oldFiber: [a, b, c, d]
newChild: [d, a, → b, c]
  • b 在oldFiber中的索引是 1,即 currentIndex = 1
  • 上一次 lastPlacedIndex 是 3
  • 由于 currentIndex < lastPlacedIndex,意味着 b 结点需要移动
  • 参照物结点的索引,lastPlacedIndex 保持 3不变

进行 newChild中 下一个结点 c 的处理

js
oldFiber: [a, b, c, d]
newChild: [d, a, b, → c]
oldFiber: [a, b, c, d]
newChild: [d, a, b, → c]
  • c 在oldFiber中的索引是 2,即 currentIndex = 2
  • 上一次 lastPlacedIndex 是 3
  • 由于 currentIndex < lastPlacedIndex,意味着 c 结点需要移动
  • 参照物结点的索引,lastPlacedIndex 保持 3不变

至此比较结束,d 结点不需要移动,而 a b c 三个结点都需要移动

总结

  • [a, b, c, d] => [a, c, d, b] 需要移动 b 一个结点
  • [a, b, c, d] => [d, a, b, c] 需要移动a、b、c三个结点

从两个示例中可以看出,应尽量减少后面的结点移动到前面,来提高性能。